package com.obixlabs.security.credentialsstore.server.dao.ldapimpl;

import static com.obixlabs.security.credentialsstore.server.dao.ldapimpl.LDAPConstants.COMMON_NAME_ATTRIBUTE;
import static com.obixlabs.security.credentialsstore.server.dao.ldapimpl.LDAPConstants.EMAIL_ADDRESS_ATTR_NAME;
import static com.obixlabs.security.credentialsstore.server.dao.ldapimpl.LDAPConstants.USER_OBJ_CLASS_ATTR_VALS;

import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;

import javax.naming.NameAlreadyBoundException;
import javax.naming.NameNotFoundException;
import javax.naming.NamingEnumeration;
import javax.naming.NamingException;
import javax.naming.directory.Attribute;
import javax.naming.directory.Attributes;
import javax.naming.directory.BasicAttribute;
import javax.naming.directory.DirContext;
import javax.naming.directory.ModificationItem;
import javax.naming.directory.SearchControls;
import javax.naming.directory.SearchResult;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.obixlabs.commons.ExceptionUtil;
import com.obixlabs.commons.naming.NamingUtils;
import com.obixlabs.security.credentialsstore.exception.EntryNotFoundException;
import com.obixlabs.security.credentialsstore.exception.SecurityRuntimeException;
import com.obixlabs.security.credentialsstore.model.BaseUser;
import com.obixlabs.security.credentialsstore.model.ObixGroup;
import com.obixlabs.security.credentialsstore.model.ObixGroups;
import com.obixlabs.security.credentialsstore.model.User;
import com.obixlabs.security.credentialsstore.model.UserGroups;
import com.obixlabs.security.credentialsstore.server.dao.UserDAO;
import com.obixlabs.security.credentialsstore.server.dao.ldapimpl.config.LDAPConnectionInfo;

public class UserDAOImpl extends AbstractLDAPDAO implements UserDAO 
{
	private static final Logger logger = 
		LoggerFactory.getLogger(UserDAOImpl.class);
	
	private GroupDAOImpl groupDAO;
	private LDAPConnectionInfo connectionInfo;
	
	public GroupDAOImpl getGroupDAO() {return groupDAO;}
	public void setGroupDAO(GroupDAOImpl groupDAO) {this.groupDAO = groupDAO;}
	
	public LDAPConnectionInfo getConnectionInfo(){return connectionInfo;}
	public void setConnectionInfo(LDAPConnectionInfo connectionInfo)
	{this.connectionInfo = connectionInfo;}	
	
	@Override
	public Set<BaseUser> findByGroup(ObixGroup group) throws EntryNotFoundException
	{
		Set<BaseUser> result = new HashSet<BaseUser>();		
		List<String> userDns = getGroupDAO().getGroupMemberDNs(group);
		BaseUser user;
		for (String dn : userDns)
		{
			user = extractUserDetailsFromDN(dn);
			result.add(user);
		}
		return result;
	}
		
	@Override
	public void delete(BaseUser user) 
	{
		String userDN = LDAPAttributesFactory.getUserDN(user,
														getUserCategorizationConfig());				
		try
		{
			//discontinue group memberships,
			//using current group list i.e. don't rely on data 
			//suppplied by user
			ObixGroups currentMemberships = getGroupDAO().findByUser(user);
			for (ObixGroup obixGroup : currentMemberships.values())
				getGroupDAO().removeUserFromGroup(obixGroup, user);
			
			//then delete subcontect -- the user details
			getAdminConnection().getContext().destroySubcontext(userDN);
		}
		catch (NameNotFoundException exception)
		{
			String errorMessage = "User (" + userDN
					+ ") does not exist "
					+ "in the directory. Request will be ignored!";
			logger.warn(errorMessage, exception);
		}
		catch (NamingException exception)
		{
			throw new SecurityRuntimeException("Could not delete User ("
					+ userDN + ") from the directory due to unexpected error." +
					"\n\tStack Trace:\n" + ExceptionUtil.getStackTrace(exception));
		}			
	}

	@Override
	public BaseUser findByUserIdAndPassword(String username, String password) 
	{ return internalAuthenticateUser(username, password, false); }

	@Override
	public BaseUser findByEmailAddress(String emailAddress)
	{
		BaseUser result;
		try
		{
			//change case to lowercase
			emailAddress = emailAddress.toLowerCase();
			
	        // Set up criteria to search on
        	StringBuffer filter = new StringBuffer();        
		    filter.append("(&");
		    for (String objectClass : USER_OBJ_CLASS_ATTR_VALS)
		    	filter.append("(objectClass=" +objectClass+")");
		    
		    filter.append("("+ EMAIL_ADDRESS_ATTR_NAME +"=" + emailAddress + ")");
		    
		    filter.append(")");	        		        
	
	        // Set up search constraints
	        SearchControls cons = new SearchControls();
	        cons.setSearchScope(SearchControls.ONELEVEL_SCOPE);
	
	        NamingEnumeration<SearchResult> results =
	            getAdminConnection().getContext().search(
	            			getCategorizationInfo().getUserCategorizationConfig().getSearchBaseDN(), 
	            			filter.toString(), cons);
	        
	        SearchResult searchResult;	        
	        if (results.hasMore()) 
	        {
	        	searchResult = results.next();
	        	result = new BaseUser();
	        	LDAPAttributesFactory.extractAttributeValsIntoBaseUser(
	        							searchResult.getAttributes(), result);
	        	refreshGroupMemberships(result);
	        }
	        else result = null;
		}
		catch (NamingException namingException)
		{
			result = null;
			logger.warn("Unable to find user by email address '"+ emailAddress+"'!",
							namingException);
		}
		return result;
	}
	
	@Override
	public User findHumanUserByIdAndPassword(String username, String password) 
	{ return (User) internalAuthenticateUser(username, password, true); }
	
	@Override
	public BaseUser findByUserId(String username) 
	{ return internalFindByUserId(username, false); }
	
	@Override
	public User findHumanUserByUserId(String username)
	{ return (User) internalFindByUserId(username, true); }
	
	@Override
	public BaseUser save(BaseUser user) 
	{
		//normalise userId
		user.setUserId(user.getUserId().toLowerCase());
		user.setEmailAddress(user.getEmailAddress().toLowerCase());
		
		BaseUser existingRecord = findByUserId(user.getUserId());
		if (existingRecord!=null)
			internalUpdateOld(existingRecord,user);
		else internalSaveNew(user);
		
		refreshGroupMemberships(user);
		return user;
	}
	
	private void internalUpdateOld(BaseUser oldState, BaseUser newState)
	{
		String userDN = 
			LDAPAttributesFactory.getUserDN(oldState, 
											getUserCategorizationConfig());
		try 
		{
			//we only allow modification of email addresses and passwords
			List<ModificationItem> modifications = new ArrayList<ModificationItem>();
					
			ModificationItem modificationItem;
			if (!oldState.getEmailAddress().equals(newState.getEmailAddress()))
			{
				Attribute emailAddress = 
					new BasicAttribute(LDAPConstants.EMAIL_ADDRESS_ATTR_NAME, 
									newState.getEmailAddress());
			
				modificationItem = new ModificationItem(
									DirContext.REPLACE_ATTRIBUTE, emailAddress);
				modifications.add(modificationItem);
			}
			
			if (newState.getPassword()!=null &&
					newState.getPassword().length()>0 &&
					!oldState.getPassword().equals(newState.getPassword()))
			{			
				Attribute password = 
					new BasicAttribute(LDAPConstants.PASSWORD_ATTR_NAME, 
										newState.getPassword());
				modificationItem = 
						new ModificationItem(DirContext.REPLACE_ATTRIBUTE, 
											password);
				modifications.add(modificationItem);
			}
			
			//overwrite first, last and common name always
			//this is neater than trying top figure out if the old
			//account included theses details and trying to
			//figure out what they are
			if (newState instanceof User)
			{
				User newUserDetails = (User) newState;
				Attribute firstName =
					new BasicAttribute(LDAPConstants.USER_GIVEN_NAME_ATTR_NAME,
										newUserDetails.getFirstName());
				modificationItem = 
					new ModificationItem(DirContext.REPLACE_ATTRIBUTE, firstName);
				modifications.add(modificationItem);
				
				Attribute lastName =
					new BasicAttribute(LDAPConstants.USER_SURNAME_ATTR_NAME,
										newUserDetails.getLastName());
				modificationItem = 
					new ModificationItem(DirContext.REPLACE_ATTRIBUTE, lastName);
				modifications.add(modificationItem);
				
				Attribute commonName = new BasicAttribute(COMMON_NAME_ATTRIBUTE, 
										LDAPAttributesFactory.createCommonName(
												 newUserDetails.getFirstName(), 
												 newUserDetails.getLastName()));
				modificationItem = 
					new ModificationItem(DirContext.REPLACE_ATTRIBUTE, commonName);
				modifications.add(modificationItem);				
			}
			
			//now apply updates
			if (!modifications.isEmpty())
			{
				getAdminConnection().getContext().modifyAttributes(	
							userDN, 
							modifications.toArray(new ModificationItem[0]));
			}
			
			//and then update groups
			updateMemberships(newState);
		} 
		catch (NamingException exception) 
		{
			String errorMessage = "Unexpected Error: Could not update user ("
					+ userDN + "). Possible LDAP integrity error." + 
					"\n\tStack Trace:\n"
					+ ExceptionUtil.getStackTrace(exception);
			throw new SecurityRuntimeException(errorMessage);
		}		
	}
	
	private void internalSaveNew(BaseUser user)
	{
        Attributes container;
        
        if (user instanceof User)
        	container = LDAPAttributesFactory.
        				createHumanUserAttributes((User)user);
        else container =LDAPAttributesFactory. createUserAttributes(user, true);        	
        
        LDAPAttributesFactory.addOrgIdentificationAttributes(	container, 
				getCategorizationInfo().getUserCategorizationConfig());        
        
        String userDN = LDAPAttributesFactory.getUserDN(user,getUserCategorizationConfig());
                
        
        try
        {
        	getAdminConnection().getContext().createSubcontext(userDN,container);
        	updateMemberships(user);
        }
        catch (NameAlreadyBoundException userAlreayExists)
        {
        	String errorMessage = 	
        			"User (" + userDN + ") already exists " +
        			"in the Obix directory. Invocation will be ignored!";
        	logger.warn(errorMessage,userAlreayExists);	
        }
        catch (NamingException exce)
        {
        	throw new SecurityRuntimeException(
        			"Could not persist user to LDAP directory due to " +
        			"unexpected application/infrastructure error!"+
					ExceptionUtil.getStackTrace(exce));
        }		
	}
	
    private BaseUser internalAuthenticateUser(	String username, 
    											String password,
    											boolean humanUser)
	{    	
		BaseUser result;
		
		DirContext context = null;
		try
		{
			username = username.toLowerCase(); //ensure in lowercase
			
			String userDN = LDAPAttributesFactory.getUserDN(username,
					getUserCategorizationConfig());

			// if we can connect, then the user is ok
			context = LDAPContextFactory.createLDAPContext(
					getConnectionInfo(), userDN, password);

			Attributes attributes = context.getAttributes(LDAPAttributesFactory
					.getUserDN(username, getUserCategorizationConfig()));
						
			if (!humanUser)
			{
				result = new BaseUser();
				LDAPAttributesFactory.extractAttributeValsIntoBaseUser(attributes,
																		result);
			}
			else result = 
					LDAPAttributesFactory.extractUserDetailsFromAttributes(attributes);
						
			refreshGroupMemberships(result);
		}
		catch (NamingException namingException)
		{			
			result = null;
			logger.warn("Unable to connect to LDAP directory as '"+username+"' due "
							+ "to naming error. Likely cause is bad credentials!");			
		}
		finally{ NamingUtils.close(context);}
		
		
		return result;
	}

	private BaseUser internalFindByUserId(String username, boolean isHumanUser) 
	{
		BaseUser result;
		
		try
		{
			username = username.toLowerCase();
			// Set up criteria to search on
			StringBuffer filter = new StringBuffer();

			filter.append("(&");

			for (String objectClass : LDAPConstants.USER_OBJ_CLASS_ATTR_VALS)
				filter.append("(objectClass=" + objectClass + ")");

			filter	.append("(" + LDAPConstants.USERID_ATTR_NAME + "=")
					.append(username).append(")")
					.append(")");

			// Set up search constraints
			SearchControls cons = new SearchControls();
			cons.setSearchScope(SearchControls.ONELEVEL_SCOPE);

			NamingEnumeration<SearchResult> results = 
				getAdminConnection().
				getContext().search(getCategorizationInfo().getUserCategorizationConfig().getSearchBaseDN(), 
									filter.toString(), cons);

			SearchResult searchResult;
			Attributes userAttributes;
			if (results.hasMore())
			{
				searchResult = results.next();
				userAttributes = searchResult.getAttributes();
				
				if (isHumanUser)
				{
					result = LDAPAttributesFactory.extractUserDetailsFromAttributes(
																		userAttributes);
				} 
				else 
				{
					result = new BaseUser();
					LDAPAttributesFactory.extractAttributeValsIntoBaseUser(userAttributes,
																			result);					
				}
				refreshGroupMemberships(result);
			}
			else result = null;
		}
		catch (NamingException exception)
		{
			throw new SecurityRuntimeException(
					"Could not search LDAP directory due "
							+ "to unexpected exception.\n\tStack Trace:\n"
							+ ExceptionUtil.getStackTrace(exception));
		}
		return result;
	} 
	
	private void refreshGroupMemberships(BaseUser user)
	{
		// populate group data
		ObixGroups groups = getGroupDAO().findByUser(user);
		user.setGroups(new UserGroups(groups));		
	}
	
	private void updateMemberships(BaseUser user)
	{
		UserGroups groups = user.getGroups();
		if (groups==null) return;
		
		//update discontinued memberships
		Set<ObixGroup> discontinuedMemberships = 
			groups.getDiscontinuedMemberships();
		for (ObixGroup obixGroup : discontinuedMemberships)
			getGroupDAO().removeUserFromGroup(obixGroup, user);
		
		//then join any new groups -- this sequence matters in 
		//case a membership is revoked and re-assigned 
		//in the same request		
		Set<ObixGroup> newMemberships = groups.getNewMemberships();
		for (ObixGroup obixGroup : newMemberships)
			getGroupDAO().addUserToGroup(obixGroup, user);		
	}

	private BaseUser extractUserDetailsFromDN(String dn) 
	{
		BaseUser result;
		try
		{			
			Attributes userAttributes = 
					getAdminConnection().getContext().getAttributes(dn);
			result = new BaseUser();
			LDAPAttributesFactory.extractAttributeValsIntoBaseUser(userAttributes, result);
			
			refreshGroupMemberships(result);
		}
		catch (NamingException namingExce)
		{
			throw new SecurityRuntimeException("Directory inconsistency detected. " +
					"Could not find attributes for user-dn \"" + dn + "\"",
					namingExce);
		}
		return result;
	}
}